Skip to content

JavaScript Modules

For a long time, JavaScript had no built-in module system.

In the early days, we wrote our JavaScript programs in .js files, and loaded them all up via <script> tags in our HTML files. This worked alright, but it meant that every script shared the same environment; variables declared in one file could be accessed in another. It was a bit of a mess.

Several third-party solutions were invented, around 2009-2010. The most popular were RequireJS and CommonJS. CommonJS is the module.exports thing you might've seen in back-end Node.js code.

Starting in the mid-2010s, however, JS got its own native module system! And it's pretty cool.

Basic premise

When we work with a JS module system, every file becomes a "module". A module is a JavaScript file that can contain one or more exports. We can pull the code from one module into another using the import statement.

If we don't export a piece of data from one module, it won't be available in any other modules. The only way to share anything between modules is through import/export.

In addition to the code we write in our codebase, we can also import third-party modules like React.

The benefit of this rather complex system is that everything is isolated by default. We have to go out of our way to share data between modules. Imports/exports are the "bridges" between modules, and by strategically placing them, we can make complex programs easier to reason about.

Named exports

Each file can define one or more named exports:

Code Playground

Open in CodeSandbox
export const significantNum = 5;

export function doubleNum(num) {
return num * 2;
}

In this case, our data.js module uses the export keyword to make a piece of data, significantNum, available to other files.

We're also exporting a function, doubleNum. We can export any JavaScript data type, including functions and classes.

In our main file, index.js, we're importing both of these exports:

import { significantNum, doubleNum } from './data';

Inside the curly braces, we list each of the imports we want to bring in, by name. We don't have to import all of the exports, we can pick just the ones we need.

The string at the end, './data', is the path to the module. We're allowed to omit the .js suffix, since it's implied.

The module system uses a linux-style relative path system to locate modules. A single dot, ., refers to the same directory. Two dots, .., refers to a parent directory. If you're not familiar with this system, check out this quick primer.

Export statements

It's conventional to export variables as they're declared, like this:

export const significantNum = 5;

It's also possible to export previously-declared variables using squiggly brackets, like this:

const significantNum = 5;
export { significantNum };

Curiously, this syntax is quite rare — I only recently learned it was possible to do this! When it comes to named exports, it's much more common to export them right when they're declared.

We can also export functions as they're declared, like this:

// Produces a named export called `someFunction`:
export function someFunction() { /* ... */ }

Renaming imports

Sometimes, we'll run into naming collisions with named imports:

import { Wrapper } from './Header';
import { Wrapper } from './Footer';
// 🚫 Identifier 'Wrapper' has already been declared.

This happens because named exports don't have to be globally unique. It's perfectly valid for both Header and Footer to use the same name:

// Header.js
export function Wrapper() {
return <header>Hello</header>;
}
// Footer.js
export function Wrapper() {
return <footer>World</footer>;
}

We can rename imports with the as keyword:

import { Wrapper as HeaderWrapper } from './Header';
import { Wrapper as FooterWrapper } from './Footer';
// ✅ No problems

Within the scope of this module, HeaderWrapper will refer to the Wrapper function exported from the /Header.js module. Similarly, FooterWrapper will refer to the other Wrapper function exported from /Footer.js.

Default exports

There's a separate type of export in JS modules: the default export.

Let's look at an example:

Code Playground

Open in CodeSandbox
const magicNumber = 100;

export default magicNumber;

When it comes to default exports, we always export an expression:

// ✅ Correct:
const hi = 5;
export default hi;
// 🚫 Incorrect
export default const hi = 10;

Every JS module is limited to a single default export. For example, this is invalid:

const hi = 5;
const bye = 10;
export default hi;
export default bye;
// 🚫 SyntaxError: Too many default exports!

When importing default exports, we don't use squiggly brackets:

// ✅ Correct:
import magicNumber from './data';
// 🚫 Incorrect:
import { magicNumber } from './data';

When we're importing a default export, we can name it whatever we want; it doesn't have to match:

// ✅ This works
import magicNumber from './data';
// ✅ This also works!
import helloWorld from './data';

A lot of these differences might seem arbitrary or confusing, but they all stem from this fact: Every module can have multiple named exports, but only a single default export.

For example, we need to use the correct name when importing a named export because we need to specify which export we want! But because there's only a single default export, we can name it whatever we want, there's no ambiguity.

When to use which

Now that we've covered the fundamentals about named and default exports, you might be wondering: when should I use each type?

In fact, this is a question with no objectively “correct” answer. It all comes down to picking a convention that works for you.

For example, let's say that we have a file that holds a bunch of theme constants (colors and sizes and fonts and stuff). We could structure it like this:

Code Playground

const THEME = {
colors: {
red: 'hsl(333deg 100% 45%)',
blue: 'hsl(220deg 80% 40%)',
},
spaces: [
'0.25rem',
'0.5rem',
'1rem',
'1.5rem',
'2rem',
'4rem',
'8rem',
],
fonts: {
default: '"Helvetica Neue", sans-serif',
mono: '"Fira Code", monospace',
},
}

export default THEME;

Or, we could use named exports:

Code Playground

export const COLORS = {
red: 'hsl(333deg 100% 45%)',
blue: 'hsl(220deg 80% 40%)',
};

export const SPACES = [
'0.25rem',
'0.5rem',
'1rem',
'1.5rem',
'2rem',
'4rem',
'8rem',
];

export const FONTS = {
default: '"Helvetica Neue", sans-serif',
mono: '"Fira Code", monospace',
}

We can use either a single "grouped" default export, or lots of individual named exports. Both of these options are perfectly valid.

Here's a convention I like to follow, though: if a file has one obvious "main" thing, I make it the default export. Secondary things, like helpers and metadata, can be exported using named exports.

For example, a React component might be set up like this:

// components/Header.js
export const HEADER_HEIGHT = '5rem';
function Header() {
return (
<header style={{ height: HEADER_HEIGHT }}>
{/* Stuff here */}
</header>
)
}
export default Header;

The main “thing” in this Header.js file is the Header component, and so it uses the default export. Anything else will use named exports.